print("程序正在加载所需要的依赖库中...")

from threading import Thread, Lock  # 线程库
from time import sleep, strftime, localtime  # 时间库
from tkinter import filedialog, Tk, messagebox  # 打开文件库
from pandas import read_excel, DataFrame  # 读取
from pynput import keyboard  # 键盘监控
from win32api import SetCursorPos, mouse_event  # 设置光标，鼠标点击事件
from win32gui import FindWindow, GetWindowPlacement, ShowWindow, SetWindowPos, IsWindowVisible, IsWindowEnabled, \
    IsWindow, PostMessage
from win32con import MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, SW_SHOWDEFAULT, HWND_TOPMOST, \
    HWND_NOTOPMOST, SWP_SHOWWINDOW, WM_CLOSE  # 控制窗体大小和鼠标移动
from pykeyboard import PyKeyboard  # 控制输入ctrl+f等功能
from pyperclip import copy  # 复制文字
from argparse import ArgumentParser  # 命令行输入
from datetime import datetime  # 时间，用来计算时间
from sys import setrecursionlimit  # 用来设置递归深度
from os.path import exists, split, join

setrecursionlimit(100000)  # 设置递归深度

__author__ = '广大菜鸟'
isEnd = False
listener = None
standardHeight = 1000  # 通过训练获取错误的下拉长度(临界高度)
itemDataFrame = None
failedInfoDataFrame = None
lock = Lock()  # 申请一把互斥锁
default_parameter_path = r'./搜索框查询失败默认高度.config'
default_failedInfo_path = strftime("%Y-%m-%d", localtime()) + ' 没有通知成功学生名单.xls'  # 保存到桌面上


def on_activate():
    print('中断热键 <shift> 已启动.在此为本次程序异常对您造成的不便感到十分抱歉！！！')
    global isEnd
    isEnd = True
    return False


def for_canonical(f):
    global listener
    lock.acquire()  # 加锁
    pass
    lock.release()  # 释放锁
    return lambda k: f(listener.canonical(k))


hotkey = keyboard.HotKey(
    keyboard.HotKey.parse('<shift>'),
    on_activate)


def getWorkingLocation():
    window = FindWindow("WeWorkWindow", u"企业微信")  # 可以通过winSpy获得
    max_iter = 100
    while window == 0 and max_iter > 0:
        sleep(0.2)
        print("在定位窗口句柄时，发现为无效的窗口句柄。请检查是否打开企业微信的窗口后再重试此软件")
        window = FindWindow("WeWorkWindow", u"企业微信")  # 可以通过winSpy获得 (类名 ,标题)
        max_iter -= 1
    ShowWindow(window, SW_SHOWDEFAULT)  # 设置大小为正常模式,处理开启但没显示，最小化和最大化的情况
    return window, GetWindowPlacement(window)


# 输入学生的学号，前提：需要打开电脑的企业微信
def openUser(info, lang=None):
    k = PyKeyboard()
    # 0、 选择搜索页面
    window, loginid = getWorkingLocation()
    [left, top, _, _] = loginid[4]
    if left < 0:  # 处理左侧被挡住
        left = 10

    width, height = 10, 10  # 用不可能的尺寸缩小到最小
    SetWindowPos(window, HWND_TOPMOST, left, top, width, height, SWP_SHOWWINDOW)  # 先设置为置顶,不可以改变位置
    SetWindowPos(window, HWND_NOTOPMOST, left, top, width, height,
                 SWP_SHOWWINDOW)  # 修改为可改变在前面的软件界面，考虑到在遇到意外需要手动关闭终端

    [left, top, right, bottom] = GetWindowPlacement(window)[4]  # 获取最小化后的参数
    width, height = right - left, bottom - top  # 最终的参数

    # 通过像素获取左侧聊天界面按钮，同时暂时覆盖其他软件
    foreX, foreY = int(left + 31 / 897 * width), int(top + height * 110 / 712)  # 比值是通过图片像素比值获得聊天首页面

    SetCursorPos((foreX, foreY))
    mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)  # press mouse
    mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)  # release mouse

    secondX, secondY = int(left + 154 / 919 * width), int(
        top + height * 108 / 650)  # 比值是通过图片像素比值获得名片选择框  **根据自己电脑，改这里的参数**

    thirdX, thirdY = int(right - 0.1 * width), int(bottom - height * 0.1)  # 比值是通过图片像素比值获得对话框

    global isEnd
    global listener
    isEnd = False
    listener = keyboard.Listener(
        on_press=for_canonical(hotkey.press))
    listener.daemon = 1

    # 创建、启动鼠标监听线程
    t2 = Thread(target=sendInfo,
                args=(info, k, secondX, secondY, thirdX, thirdY, lang))
    t2.daemon = 1
    listener.start()
    t2.start()

    while True:
        if isEnd:
            return


# 判断是否为有效句柄
def is_valid_hwnd(hwnd):
    if IsWindow(hwnd) and IsWindowEnabled(hwnd) and IsWindowVisible(hwnd):
        return True
    return False


def CTRL_F(k):
    k.press_key(k.control_key)  # ctrl+f
    k.tap_key('f')
    k.release_key(k.control_key)


def CTRL_A(k):
    k.press_key(k.control_key)  # ctrl+A
    k.tap_key('a')
    k.release_key(k.control_key)


def CTRL_V(k):
    k.press_key(k.control_key)  # ctrl+v
    k.tap_key('v')
    k.release_key(k.control_key)


def ENTER(k):
    k.press_key(k.enter_key)  # 发送
    k.release_key(k.enter_key)


def DOWN(k):
    k.press_key(k.down_key)  # down_key
    k.release_key(k.down_key)


def sendInfo(info, k, secondX, secondY, thirdX, thirdY, lang):
    global isEnd, standardHeight, failedInfoDataFrame, itemDataFrame
    for (sno, sname) in info:
        lock.acquire()  # 加锁
        if isEnd:
            return
        CTRL_F(k)  # ctrl+F
        copy(sno)  # 复制到剪贴板
        CTRL_A(k)
        CTRL_V(k)
        sleep(0.5)  # 等程序响应
        markHwnd = FindWindow("SearchResultWindow2", 'SearchResultWindow2')  # 在查询时，这个会出现，然后下面那个会跟着出现
        tryTime = 0
        while not is_valid_hwnd(markHwnd):  # 判断是否有触发下拉
            print('\r在查询学号['+sno+']时，尝试查找下拉句柄次数:' + str(tryTime+1), end='')
            CTRL_F(k)  # ctrl+F
            CTRL_A(k)
            CTRL_V(k)
            sleep(0.3)
            tryTime += 1
        if tryTime >0:
            print()
        sleep(0.1)
        # 检查区
        [_, top, _, bottom] = GetWindowPlacement(markHwnd)[4]  # 下拉列表的坐标
        height = bottom - top

        # 1.判断输入框显示的高度的正确性
        if standardHeight == 1000 and height < standardHeight:  # 在训练阶段确定临界
            print('本次训练的高度', height, '作为本机的临界高度')
            standardHeight = height
            lock.release()  # 释放锁
            continue
        elif height == standardHeight < 1000:  # 在测试阶段遇到临界
            print('本次高度', height, '等于临界高度', standardHeight, end=' ')
            print('没有通知到位的学生：', sno, " ", sname)
            if type(itemDataFrame) == DataFrame:
                newObject = itemDataFrame.loc[itemDataFrame['学号'] == sno]
                failedInfoDataFrame = failedInfoDataFrame.append(newObject, ignore_index=False)
            lock.release()  # 释放锁
            continue
        if not is_valid_hwnd(markHwnd):  # 检查是否被关闭
            CTRL_F(k)
            sleep(0.5)

        SetCursorPos((secondX, secondY))  # 选择第一个元素
        mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)  # press mouse
        mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)  # release mouse

        # 2.判断错误窗口的存在
        errorHwnd = FindWindow("SearchExternalsWnd", u'网络查找手机/邮箱：' + sno)
        if errorHwnd != 0:
            sleep(1)
            ENTER(k)
            PostMessage(errorHwnd, WM_CLOSE, 0, 0)  # 关闭
            print('没有通知到位的学生：', sno, " ", sname)
            if type(itemDataFrame) == DataFrame:
                newObject = itemDataFrame.loc[itemDataFrame['学号'] == sno]
                failedInfoDataFrame = failedInfoDataFrame.append(newObject, ignore_index=False)
            lock.release()  # 释放锁
            continue
        # 3、输入信息
        if sname == ' ':
            msg = "你好，同学。"
        else:
            msg = "你好，" + sname + "同学。"

        if lang is not None:
            msg = msg + lang
        else:
            msg += "温馨提醒，疫情防控，从我做起。截止于下午3点半，系统显示您今日还没有上报健康信息，请尽快上报。"
        sleep(0.1)
        copy(msg)
        SetCursorPos((thirdX, thirdY))
        mouse_event(MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)  # 三击
        mouse_event(MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
        mouse_event(MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
        CTRL_V(k)
        ENTER(k)
        lock.release()  # 释放锁
        sleep(0.1)  # 给检查和处理异常的有效时间,线程可以切换检查是否有按下快捷键
    isEnd = True


def readFromExcelByPandas(filename):
    try:
        df = read_excel(filename, header=1, index_col=0, dtype=str)
        return df
    except Exception as e:
        print(str(e))
        return None


def informUsers(src, classno=None, lang=None, delGrade=None):
    global failedInfoDataFrame, itemDataFrame
    itemDataFrame = readFromExcelByPandas(src)
    if itemDataFrame is None:
        return
    itemDataFrame.fillna(' ', inplace=True)
    #  处理班级
    if classno is not None and '班级' in itemDataFrame.columns:
        target = itemDataFrame.loc[itemDataFrame['班级'] == classno]  # 建立在表格的用户的sno都存在
    else:
        target = itemDataFrame

    # 默认删除17级
    if '班级' in target.columns:
        target = target.loc[~ target['班级'].str.contains('[^0-9]+' + str(17) + r'\d')]  # 默认先删除17级
        if delGrade is not None and 10 < delGrade < 100:  # 目前可见年级有17-20，所以合理范围为2位数 去除
            target = target.loc[~ target['班级'].str.contains('[^0-9]+' + str(delGrade) + r'\d')]  # 正则表达式匹配 如"计科214"的21

    # 处理学号,规定10-17位数字组成
    target = target.loc[target['学号'].str.contains(r"^\d{10,17}$")]

    # 设置未通知成功的用户
    failedInfoDataFrame = itemDataFrame.append(target).drop_duplicates(keep=False)

    print("本次需要选择的对象数目有：", len(target))
    # openUser(list(target.groupby(['学号', '姓名']).groups.keys()), lang) # 按照学号排序
    if len(target) > 0:
        openUser(target[['学号', '姓名']].values.tolist(), lang)  # 按原来的顺序
        saveFailedInfoDataFrame()  # 记录没有通知成功的


def informUser(sno, sname, lang=None):
    openUser([(sno, sname)], lang)


# 选择文件
def selectFilename():
    global default_failedInfo_path
    root = Tk()
    root.withdraw()
    filename = filedialog.askopenfilename()
    if filename != '':
        print("您选择的文件是：" + filename)
        root_path, _ = split(filename)
        default_failedInfo_path = join(root_path, default_failedInfo_path)  # 没有通知成功的也保存在原来下路径
        return filename
    else:
        raise Exception("您没有选择任何文件")


def readDefaultStandardHeight():
    global standardHeight
    if exists(default_parameter_path):
        with open(default_parameter_path, 'r') as f:
            line = f.readline().strip()  # 一个数字
            if line != '' and line.isdigit():
                standardHeight = eval(line) if eval(line) > 0 else standardHeight


def saveDefaultStandardHeight():
    global standardHeight
    if standardHeight < 0:
        return
    with open(default_parameter_path, 'w') as f:
        f.write(str(standardHeight))


# 保存没有通知成功的
def saveFailedInfoDataFrame():
    global failedInfoDataFrame
    if failedInfoDataFrame is not None and failedInfoDataFrame.shape[0] > 0:
        print("|FAILED_INFO|本次没有通知成功的学生数量:", failedInfoDataFrame.shape[0])
        failedInfoDataFrame.to_excel(default_failedInfo_path)
        print('没有通知成功的学生信息，现已保存于', default_failedInfo_path)


# 结束通知
def EndInfo():
    root = Tk()
    root.withdraw()
    messagebox.showinfo('提示', '本次通知任务结束')


def main():
    parser = ArgumentParser()
    parser.description = '本软件功能是企业微信打卡，可以选择文件/个人模式(注：确定企业微信最新版，在启动企业微信和该程序后，不要移动鼠标和点击键盘)\n' \
                         '\n默认发送消息格式为\'你好，xxx同学。\'\n' \
                         '简单使用案例：企业微信打卡提醒 -c *  -r 21 (意思是删除表中21届的学生，不给于通知)'
    parser.add_argument("-c", "--classno", help="这个参数是班级名称(当*时，为操作所有班级数据)", type=str)
    parser.add_argument("-o", "--sno", help="这个参数是学生学号", type=str)
    parser.add_argument("-a", "--sname", help="这个参数是学生名字", type=str)
    parser.add_argument("-l", "--lang", help="这个参数是在基本发送格式[你好，xxx同学。]增加的文字。如不设置，则输出自定的格式。", type=str)
    parser.add_argument("-r", "--delGrade", help="这个参数是删除年级（2位数字，如17）", type=int, default=17)
    # parser.add_argument("-t", "--time", help="这个参数是等待响应时间（可以根据网速增加,默认0.3(单位：秒)，只能多不能少，少了肯定有异常)", type=float)
    args = parser.parse_args()
    readDefaultStandardHeight()
    print("程序依赖库已经加载完毕,可以开始自动查询发送信息")
    if args.sname is not None and args.sno is not None:
        oldtime = datetime.now()
        try:
            informUser(args.sno, args.sname, args.lang)
        except Exception as e:
            print(str(e))
        finally:
            newtime = datetime.now()
            print("本次通知使用时间为%.2fs" % ((newtime - oldtime).total_seconds()))
            saveDefaultStandardHeight()
            EndInfo()
    elif args.classno is not None:  # 文件+班级模式
        classno = args.classno if args.classno != '*' else None
        oldtime = datetime.now()  # 包括选择文件的时间
        try:
            srcFilePath = selectFilename()
            informUsers(srcFilePath, classno, args.lang, args.delGrade)
        except Exception as e:
            print(str(e))
        finally:
            newtime = datetime.now()
            print("本次通知使用时间为%.2fs" % ((newtime - oldtime).total_seconds()))
            saveDefaultStandardHeight()
            EndInfo()
    else:
        try:
            parser.error('\n请使用终端命令行启动本软件，并输入参数\n选择文件模式[-c ]时，需要指定班级名称或者用*号代表所有班级;\n选择学生模式[-o -a]时，需要指定学生学号和学生名字')
        except Exception as e:
            print(str(e))


if __name__ == '__main__':
    try:
        main()
    except Exception as e:
        print(str(e))
